package jp.co.nes.awssupport.business.logic;

import java.io.BufferedReader;
import java.io.Closeable;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.lang.reflect.Field;
import java.time.YearMonth;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import java.util.zip.GZIPInputStream;
import java.util.zip.ZipInputStream;

import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.amazonaws.services.s3.model.ObjectListing;
import com.amazonaws.services.s3.model.S3Object;
import com.amazonaws.services.s3.model.S3ObjectSummary;

import jp.co.nes.awssupport.business.annotation.BillingCsvField;
import jp.co.nes.awssupport.business.bean.BillingCsvBean;
import jp.co.nes.awssupport.business.bean.ColumnBean;
import jp.co.nes.awssupport.business.bean.ManifestBean;
import jp.co.nes.awssupport.business.utility.BillingCsvReaderHelper;
import jp.co.nes.awssupport.common.base.CommonException;
import jp.co.nes.awssupport.common.utility.AppInfoUtil;
import jp.co.nes.awssupport.common.utility.AwsS3Helper;
import jp.co.nes.awssupport.common.utility.DateUtil;

/**
 * 請求CSVReaderです。
 */
public class BillingCsvReader implements Closeable {

    /** ロガー */
    private static Logger logger = LoggerFactory.getLogger(BillingCsvReader.class);

    /** 請求CSVマニュフェスト */
    private ManifestBean manifestBean;

    /** バケット名 */
    private String bucketName;

    /** オブジェクトキー */
    private String objectKey;

    /** アクセスキー */
    private String accessKey;

    /** シークレットキー */
    private String secretKey;

    /** BillingCsvReaderヘルパー */
    private BillingCsvReaderHelper billingCsvReaderHelper;

    /** Reader */
    private BufferedReader reader;

    /** 入力ストリーム */
    private InputStream inputStream;

    /** s3ヘルパー(請求CSV読み込み用) */
    private AwsS3Helper s3Helper;

    /** ヘッダ */
    private String header;

    /** 読み込んだ文字列 */
    private String line;

    /** 請求CSV */
    private BillingCsvBean bean;

    /**
     * 請求CSVマニュフェスト、バケット名、オブジェクトキーを引数にもつコンストラクタです。<br>
     * 請求CSVマニュフェスト、バケット名、オブジェクトキーには、読み込む請求CSVの請求CSVマニュフェスト、バケット名、
     * オブジェクトキーを設定します。<br>
     * デフォルトの接続先から読み込みます。
     *
     * @param manifestBean 請求CSVマニュフェスト
     * @param bucketName バケット名
     * @param objectKey オブジェクトキー
     */
    public BillingCsvReader(ManifestBean manifestBean, String bucketName, String objectKey) {
        this(manifestBean, bucketName, objectKey, null, null);
    }

    /**
     * 請求CSVマニュフェスト、バケット名、オブジェクトキー、トアクセスキー、シークレットキーを引数にもつコンストラクタです。<br>
     * 請求CSVマニュフェスト、バケット名、オブジェクトキーには、読み込む請求CSVの請求CSVマニュフェスト、バケット名、
     * オブジェクトキーを設定します。<br>
     * アクセスキー、シークレットキーには、読み込む請求CSVの接続先を設定します。<br>
     * アクセスキーがnullの場合は、デフォルトの接続先になります。
     *
     * @param manifestBean 請求CSVマニュフェスト
     * @param bucketName バケット名
     * @param objectKey オブジェクトキー
     * @param accessKey アクセスキー
     * @param secretKey シークレットキー
     */
    public BillingCsvReader(ManifestBean manifestBean, String bucketName, String objectKey, String accessKey,
            String secretKey) {
        this.manifestBean = manifestBean;
        this.bucketName = bucketName;
        this.objectKey = objectKey;
        this.accessKey = accessKey;
        this.secretKey = secretKey;
        billingCsvReaderHelper = createBillingCsvReaderHelper();
        reader = createBufferedReader();
    }

    /**
     * JUnitによるテストの中で使用するコンストラクタです。<br>
     * JUnitによるテスト以外で使用しないでください。
     *
     * @param manifestBean 請求CSVマニュフェスト(テスト用)
     * @param reader Reader(テスト用)
     */
    public BillingCsvReader(ManifestBean manifestBean, BufferedReader reader) {
        this.manifestBean = manifestBean;
        billingCsvReaderHelper = createBillingCsvReaderHelper();
        this.reader = reader;
    }

    /**
     * 請求CSV(分割後)の一覧を返却します。
     *
     * @param bucketName バケット名
     * @param targetYearMonth 処理対象年月
     * @return 請求CSV(分割後)の一覧
     */
    public static List<S3ObjectSummary> getSplitBillingCsvList(String bucketName, YearMonth targetYearMonth) {
        String targetYearMonthValue = DateUtil.toString(targetYearMonth);

        AwsS3Helper helper = new AwsS3Helper();
        ObjectListing objectListing = helper.listObjects(bucketName, targetYearMonthValue);
        return objectListing.getObjectSummaries();
    }

    /**
     * 一括請求アカウントIDを返却します。
     *
     * @param bucketName バケット名
     * @param targetYearMonth 処理対象年月
     * @param linkedAccountId 連結アカウントID
     * @return 一括請求アカウントIDリスト
     */
    public static List<String> getPayerAccountId(String bucketName, YearMonth targetYearMonth, String linkedAccountId) {
        return getPayerAccountId(getSplitBillingCsvList(bucketName, targetYearMonth), linkedAccountId);
    }

    /**
     * 一括請求アカウントIDを返却します。
     *
     * @param s3ObjectSummaryList 請求CSV(分割後)の一覧
     * @param linkedAccountId 連結アカウントID
     * @return 一括請求アカウントIDリスト
     */
    public static List<String> getPayerAccountId(List<S3ObjectSummary> s3ObjectSummaryList, String linkedAccountId) {
        List<String> resultList = new ArrayList<>();
        List<S3ObjectSummary> filterList = getS3ObjectSummary(s3ObjectSummaryList, linkedAccountId);
        for (S3ObjectSummary s3ObjectSummary : filterList) {
            String objectKey = s3ObjectSummary.getKey();

            int index = slashIndexOf(objectKey) + 1;
            objectKey = objectKey.substring(index);
            resultList.add(objectKey.substring(0, slashIndexOf(objectKey)));
        }
        return resultList;
    }

    /**
     * 請求CSV(分割後)のサイズを返却します。
     *
     * @param bucketName バケット名
     * @param targetYearMonth 処理対象年月
     * @param linkedAccountId 連結アカウントID
     * @return 請求CSV(分割後)のサイズ
     */
    public static long getSize(String bucketName, YearMonth targetYearMonth, String linkedAccountId) {
        long result = 0L;
        List<S3ObjectSummary> filterList = getS3ObjectSummary(getSplitBillingCsvList(bucketName, targetYearMonth),
                linkedAccountId);
        for (S3ObjectSummary s3ObjectSummary : filterList) {
            result = result + s3ObjectSummary.getSize();
        }
        return result;
    }

    /**
     * 連結アカウントIDに対応するS3ObjectSummaryを返却します。
     *
     * @param s3ObjectSummaryList 請求CSV(分割後)の一覧
     * @param linkedAccountId 連結アカウントID
     * @return S3ObjectSummaryリスト
     */
    private static List<S3ObjectSummary> getS3ObjectSummary(List<S3ObjectSummary> s3ObjectSummaryList,
            String linkedAccountId) {
        List<S3ObjectSummary> filterList = s3ObjectSummaryList.stream().filter(summary -> {
            String key = summary.getKey();
            return !StringUtils.isEmpty(key) && key.indexOf(linkedAccountId) >= 0;
        }).collect(Collectors.toList());
        if (filterList.size() <= 0) {
            return null;
        }
        return filterList;
    }

    /**
     * スラッシュを検索し、そのインデックスを返却します。
     *
     * @param objectKey 検索する内容
     * @return インデックス
     */
    private static int slashIndexOf(String value) {
        int index = value.indexOf("/");
        if (index < 0) {
            throw new CommonException("value is wrong(value=" + value + ")");
        }
        return index;
    }

    /**
     * isHeaderを返却します。
     *
     * @return true: 解析した行がヘッダ行の場合, false: 解析した行がヘッダ行でない場合
     */
    public boolean isHeader() {
        return billingCsvReaderHelper.isHeader();
    }

    /**
     * isInvalidLineを返却します。
     *
     * @return true: 無効行, false: 有効行
     */
    public boolean isInvalidLine() {
        return billingCsvReaderHelper.isInvalidLine();
    }

    /**
     * ヘッダの内容を返却します。<br>
     * なお、ヘッダが読み込まれるまではnullを返却します。
     *
     * @return ヘッダの内容
     */
    public String getHeader() {
        return header;
    }

    /**
     * 読み込んだ文字列を返却します。
     *
     * @return 読み込んだ文字列
     */
    public String getLine() {
        return line;
    }

    /**
     * 読み込んで請求CSVに変換した内容を返却します。
     *
     * @return 請求CSV
     */
    public BillingCsvBean getBean() {
        return bean;
    }

    /**
     * 請求CSVに変換して、読み込みます。<br>
     * 読み込んだ内容がnullの場合(読み込み終了時)は、falseを返却します。
     *
     * @return true: 読み込んだ内容がnullでない、false: 読み込んだ内容がnullである。
     * @throws IOException IO例外
     */
    public boolean readBean() throws IOException {
        line = reader.readLine();
        if (line == null) {
            String compression = manifestBean.getCompression();
            if (!StringUtils.isEmpty(compression) && compression.toLowerCase().equals("zip")) {
                if (logger.isDebugEnabled()) {
                    logger.debug("compression=" + compression);
                }
                // zipの場合、以下の読み込み処理を実施しないと、クローズ時に、S3AbortableInputStreamクラスから
                // 「Not all bytes were read from the S3ObjectInputStream, aborting HTTP connection.
                // This is likely an error and may result in sub-optimal behavior.
                // Request only the bytes you need via a ranged GET or drain the input stream after use.」
                // というワーニングが出力される場合がある(請求CSVを分割する処理で、複数のzipファイルを
                // 読み込んだ場合の、1つ目を読み込んだ後に出力されたことは確認)。
                // よって、正常に読み込みが終了した後に、以下の読み込み処理を実施している。
                // これで、正常時には、ワーニングから出力されない(異常発生時は状況による)。
                if (inputStream != null) {
                    logger.debug("inputStream is not null");
                    while (inputStream.read() >= 0) {
                        logger.debug("inputStream.read()");
                    }
                }
            }
            return false;
        }

        if (line.length() == 0) {
            // 空文字列は請求CSVに設定されていない想定なので、例外を発生させる。
            throw new CommonException("line is empty");
        }

        bean = billingCsvReaderHelper.parseLine(line);
        if (isHeader()) {
            logger.debug("header line setting");
            header = line;
        }
        return true;
    }

    /*
     * (非 Javadoc)
     * @see java.io.Closeable#close()
     */
    @Override
    public void close() throws IOException {
        logger.debug("BillingCsvReader close");
        try {
            if (inputStream != null) {
                inputStream.close();
            }
        } catch (IOException e) {
            // ワーニングログを出力し、処理を続行する。
            logger.warn(e.getMessage(), e);
        }

        try {
            if (reader != null) {
                reader.close();
            }
        } catch (IOException e) {
            // ワーニングログを出力し、処理を続行する。
            logger.warn(e.getMessage(), e);
        } finally {
            manifestBean = null;
            bucketName = null;
            objectKey = null;
            accessKey = null;
            secretKey = null;
            billingCsvReaderHelper = null;
            reader = null;
            inputStream = null;
            s3Helper = null;
            header = null;
            line = null;
            bean = null;
        }
    }

    /**
     * BillingCsvReaderヘルパーを生成します。
     *
     * @return BillingCsvReaderヘルパー
     */
    protected BillingCsvReaderHelper createBillingCsvReaderHelper() {
        // マニュフェストのcolumnsの順番からCSVに出力されている列の順番を判断する。
        Map<String, Integer> columnMap = new HashMap<>();
        List<ColumnBean> columnList = manifestBean.getColumns();
        for (int i = 0; i < columnList.size(); i++) {
            ColumnBean cBean = columnList.get(i);
            String key = cBean.getCategory() + "/" + cBean.getName();
            columnMap.put(key, i);
        }

        Map<String, Integer> fieldNumberMap = new HashMap<>();
        Field[] fields = BillingCsvBean.class.getDeclaredFields();
        for (Field field : fields) {
            BillingCsvField billingCsvField = field.getDeclaredAnnotation(BillingCsvField.class);
            if (billingCsvField == null) {
                continue;
            }

            String category = billingCsvField.category();
            String name = billingCsvField.name();
            if (StringUtils.isEmpty(name)) {
                // アノテーションでname属性が省略されている場合は、フィールド名(先頭を大文字にする)を使用する。

                String fieldName = field.getName();
                if (StringUtils.isEmpty(fieldName)) {
                    // 通常発生しない。
                    throw new CommonException("fieldName is wrong");
                }
                name = fieldName.substring(0, 1).toUpperCase() + fieldName.substring(1);
            }

            String key = category + "/" + name;
            if (columnMap.containsKey(key)) {
                // 請求CSVのフィールドが、CSVに出力されている列の、どの順番にあたるかをマップに設定する。
                fieldNumberMap.put(field.getName(), columnMap.get(key));
            } else {
                StringBuilder builder = new StringBuilder();
                builder.append("column not exist in manifest(account=");
                builder.append(manifestBean.getAccount());
                builder.append(",bucket=");
                builder.append(manifestBean.getBucket());
                builder.append(",assemblyId=");
                builder.append(manifestBean.getAssemblyId());
                builder.append(",not exist column=");
                builder.append(key);
                builder.append(")");
                if (billingCsvField.required()) {
                    // BillingCsvBeanのフィールドに対応するcolumnの情報が必須で必要だが、
                    // columnの情報が、マニュフェストのcolumnsに存在しなかった場合、例外を発生させる。
                    // # 例外を発生させずに、続行すると、存在しなかったことに気がつかずに処理され、
                    // # 適切な内容にならない可能性がある。
                    throw new CommonException(builder.toString());
                } else {
                    // BillingCsvBeanのフィールドに対応するcolumnの情報が必須で必要でない場合は、
                    // ワーニングを出力し、処理を続行する。
                    logger.warn(builder.toString());
                }
            }
        }

        String category = "BillingCsv";
        String billingCsvZoneId = AppInfoUtil.getVal(category, "ZoneId", "UTC");
        if (logger.isDebugEnabled()) {
            logger.debug(category + ",ZoneId=" + billingCsvZoneId);
        }
        return new BillingCsvReaderHelper(fieldNumberMap, billingCsvZoneId);
    }

    /**
     * 請求CSVを読み込むBufferedReaderを生成します。
     *
     * @return 請求CSVを読み込むBufferedReader
     */
    protected BufferedReader createBufferedReader() {
        // CSVファイルの入力ストリームを取得
        // AwsS3Helperの中で使用しているAmazonS3Clientから取得したS3のストリームのリード中に、
        // AmazonS3ClientがGCされるとリード中にエラーが発生するという情報があるので、
        // AwsS3Helperインスタンスおよび内部で使用しているAmazonS3Clientインスタンスのスコープには注意する必要有。
        if (accessKey == null) {
            s3Helper = new AwsS3Helper();
        } else {
            s3Helper = new AwsS3Helper(accessKey, secretKey);
        }
        S3Object s3Object = s3Helper.getObject(bucketName, objectKey);
        inputStream = s3Object.getObjectContent();
        if (inputStream == null) {
            StringBuilder builder = new StringBuilder();
            builder.append("S3ObjectInputStream is null(bucketName=");
            builder.append(bucketName);
            builder.append(",objectKey=");
            builder.append(objectKey);
            builder.append(")");
            throw new CommonException(builder.toString());
        }

        String charset = manifestBean.getCharset();
        if (StringUtils.isEmpty(charset)) {
            charset = "UTF-8";
        }
        if (logger.isDebugEnabled()) {
            logger.debug("charset=" + charset);
        }

        BufferedReader reader = null;
        try {
            String compression = manifestBean.getCompression();
            if (!StringUtils.isEmpty(compression) && compression.toLowerCase().equals("zip")) {
                ZipInputStream zipInputStream = new ZipInputStream(inputStream);
                zipInputStream.getNextEntry();
                reader = new BufferedReader(new InputStreamReader(zipInputStream, charset));
            } else if (!StringUtils.isEmpty(compression) && compression.toLowerCase().equals("gzip")) {
                GZIPInputStream gzipInputStream = new GZIPInputStream(inputStream);
                reader = new BufferedReader(new InputStreamReader(gzipInputStream, charset));
            } else {
                throw new CommonException("compression wrong(compression=" + compression + ")");
            }
        } catch (IOException e) {
            throw new CommonException(e);
        }
        return reader;
    }
}
